<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\MissingCallbackParameter;
use NeuronAI\Exceptions\ToolCallableNotSet;
use NeuronAI\StaticConstructor;
use NeuronAI\StructuredOutput\Deserializer\Deserializer;
use NeuronAI\StructuredOutput\Deserializer\DeserializerException;

/**
 * @method static static make(?string $name = null, ?string $description = null, array $properties = [])
 */
class Tool implements ToolInterface
{
    use StaticConstructor;

    /**
     * @var ToolPropertyInterface[]
     */
    protected array $properties = [];

    /**
     * @var ?callable
     */
    protected $callback;

    /**
     * The arguments to pass in to the callback.
     */
    protected array $inputs = [];

    /**
     * The call ID generated by the LLM.
     */
    protected ?string $callId = null;

    /**
     * The result of the execution.
     */
    protected string|null $result = null;

    /**
     * Define the maximum number of calls for the tool in a single agent session.
     */
    protected ?int $maxTries = null;

    /**
     * Tool constructor.
     *
     * @param ToolPropertyInterface[] $properties
     */
    public function __construct(
        protected string $name,
        protected ?string $description = null,
        array $properties = []
    ) {
        if ($properties !== []) {
            $this->properties = $properties;
        }
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function addProperty(ToolPropertyInterface $property): ToolInterface
    {
        $this->properties[] = $property;
        return $this;
    }

    /**
     * @return ToolPropertyInterface[]
     */
    protected function properties(): array
    {
        return [];
    }

    /**
     * @return ToolPropertyInterface[]
     */
    public function getProperties(): array
    {
        if ($this->properties === []) {
            foreach ($this->properties() as $property) {
                $this->addProperty($property);
            }
        }

        return $this->properties;
    }

    public function getRequiredProperties(): array
    {
        return \array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
            if ($property->isRequired()) {
                $carry[] = $property->getName();
            }

            return $carry;
        }, []);
    }

    public function setCallable(callable $callback): self
    {
        $this->callback = $callback;
        return $this;
    }

    public function getInputs(): array
    {
        return $this->inputs ?? [];
    }

    public function setInputs(?array $inputs): self
    {
        $this->inputs = $inputs ?? [];
        return $this;
    }

    public function getCallId(): ?string
    {
        return $this->callId;
    }

    public function setCallId(?string $callId): self
    {
        $this->callId = $callId;
        return $this;
    }

    public function getResult(): string
    {
        return $this->result;
    }

    public function setResult(mixed $result): self
    {
        $this->result = \is_array($result) ? \json_encode($result) : (string) $result;

        return $this;
    }

    public function getMaxTries(): ?int
    {
        return $this->maxTries;
    }

    public function setMaxTries(int $tries): self
    {
        $this->maxTries = $tries;
        return $this;
    }

    /**
     * Execute the client side function.
     *
     * @throws MissingCallbackParameter
     * @throws ToolCallableNotSet
     * @throws DeserializerException
     * @throws \ReflectionException
     */
    public function execute(): void
    {
        if (!\is_callable($this->callback) && !\method_exists($this, '__invoke')) {
            throw new ToolCallableNotSet('No function defined for tool execution.');
        }

        // Validate required parameters
        foreach ($this->getProperties() as $property) {
            if ($property->isRequired() && !\array_key_exists($property->getName(), $this->getInputs())) {
                throw new MissingCallbackParameter("Missing required parameter: {$property->getName()}");
            }
        }

        $parameters = \array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
            $propertyName = $property->getName();
            $inputs = $this->getInputs();

            // Normalize missing optional properties by assigning them a null value
            // Treat it as explicitly null to ensure a consistent structure
            if (!\array_key_exists($propertyName, $inputs)) {
                $carry[$propertyName] = null;
                return $carry;
            }

            // Find the corresponding input value
            $inputValue = $inputs[$propertyName];

            // If there is an object property with a class definition,
            // deserialize the tool input into an instance of that class
            if ($property instanceof ObjectProperty && $property->getClass()) {
                $carry[$propertyName] = Deserializer::make()->fromJson(\json_encode($inputValue), $property->getClass());
                return $carry;
            }

            // If a property is an array of objects and each item matches a class definition,
            // deserialize each item into an instance of that class
            if ($property instanceof ArrayProperty) {
                $items = $property->getItems();
                if ($items instanceof ObjectProperty && $items->getClass()) {
                    $class = $items->getClass();
                    $carry[$propertyName] = \array_map(fn (array|object $input): object => Deserializer::make()->fromJson(\json_encode($input), $class), $inputValue);
                    return $carry;
                }
            }

            // No extra treatments for basic property types
            $carry[$propertyName] = $inputValue;
            return $carry;

        }, []);

        $this->setResult(
            \method_exists($this, '__invoke') ? $this->__invoke(...$parameters)
                : \call_user_func($this->callback, ...$parameters)
        );
    }

    public function jsonSerialize(): array
    {
        return [
            'callId' => $this->callId,
            'name' => $this->name,
            'description' => $this->description,
            'inputs' => $this->inputs === [] ? new \stdClass() : $this->inputs,
            'result' => $this->result,
        ];
    }
}
